diff --git a/web/app.react.js b/web/app.react.js index 967e8cd2b..722dc1b20 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,384 +1,384 @@ // @flow import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import { faCalendar, faComments } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useDispatch } from 'react-redux'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { mostRecentReadThreadSelector, unreadCount, } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { Dispatch } from 'lib/types/redux-types'; import { verifyField, type ServerVerificationResult, } from 'lib/types/verify-types'; import { registerConfig } from 'lib/utils/config'; import AccountBar from './account-bar.react'; import Calendar from './calendar/calendar.react'; import Chat from './chat/chat.react'; import InputStateContainer from './input/input-state-container.react'; import LoadingIndicator from './loading-indicator.react'; import ResetPasswordModal from './modals/account/reset-password-modal.react'; import VerificationModal from './modals/account/verification-modal.react'; import FocusHandler from './redux/focus-handler.react'; -import { type NavInfo, updateNavInfoActionType } from './redux/redux-setup'; import { useSelector } from './redux/redux-utils'; import VisibilityHandler from './redux/visibility-handler.react'; import history from './router-history'; import Splash from './splash/splash.react'; import css from './style.css'; import getTitle from './title/getTitle'; +import { type NavInfo, updateNavInfoActionType } from './types/nav-types'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils'; // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; registerConfig({ // We can't securely cache credentials on web, so we have no way to recover // from a cookie invalidation resolveInvalidatedCookie: null, // We use httponly cookies on web to protect against XSS attacks, so we have // no access to the cookies from JavaScript setCookieOnRequest: false, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: 'web' }, }); type BaseProps = {| +location: { +pathname: string, ... }, |}; type Props = {| ...BaseProps, // Redux state +navInfo: NavInfo, +serverVerificationResult: ?ServerVerificationResult, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +mostRecentReadThread: ?string, +activeThreadCurrentlyUnread: boolean, +viewerID: ?string, +unreadCount: number, // Redux dispatch functions +dispatch: Dispatch, |}; type State = {| +currentModal: ?React.Node, |}; class App extends React.PureComponent { state: State = { currentModal: null, }; componentDidMount() { const { navInfo, serverVerificationResult } = this.props; if (navInfo.verify && serverVerificationResult) { if (serverVerificationResult.field === verifyField.RESET_PASSWORD) { this.showResetPasswordModal(); } else { this.setModal( , ); } } const newURL = canonicalURLFromReduxState( navInfo, this.props.location.pathname, this.props.loggedIn, ); if (this.props.location.pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { if (!_isEqual(this.props.navInfo)(prevProps.navInfo)) { const { navInfo, serverVerificationResult } = this.props; if ( navInfo.verify && !prevProps.navInfo.verify && serverVerificationResult ) { if (serverVerificationResult.field === verifyField.RESET_PASSWORD) { this.showResetPasswordModal(); } else { this.setModal( , ); } } else if (!navInfo.verify && prevProps.navInfo.verify) { this.clearModal(); } const newURL = canonicalURLFromReduxState( navInfo, this.props.location.pathname, this.props.loggedIn, ); if (newURL !== this.props.location.pathname) { history.push(newURL); } } else if (this.props.location.pathname !== prevProps.location.pathname) { const newNavInfo = navInfoFromURL(this.props.location.pathname, { navInfo: this.props.navInfo, }); if (!_isEqual(newNavInfo)(this.props.navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (this.props.loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState( this.props.navInfo, this.props.location.pathname, this.props.loggedIn, ); if (newURL !== this.props.location.pathname) { history.replace(newURL); } } } showResetPasswordModal() { const newURL = canonicalURLFromReduxState( { ...this.props.navInfo, verify: null, }, this.props.location.pathname, this.props.loggedIn, ); const onClose = () => history.push(newURL); const onSuccess = () => history.replace(newURL); this.setModal( , ); } render() { let content; if (this.props.loggedIn) { content = this.renderMainContent(); } else { content = ( ); } return ( {content} {this.state.currentModal} ); } renderMainContent() { const calendarNavClasses = classNames({ [css['current-tab']]: this.props.navInfo.tab === 'calendar', }); const chatNavClasses = classNames({ [css['current-tab']]: this.props.navInfo.tab === 'chat', }); let mainContent; if (this.props.navInfo.tab === 'calendar') { mainContent = ( ); } else if (this.props.navInfo.tab === 'chat') { mainContent = ; } const { viewerID, unreadCount: curUnreadCount } = this.props; invariant(viewerID, 'should be set'); let chatBadge = null; if (curUnreadCount > 0) { chatBadge =
{curUnreadCount}
; } return (
{mainContent}
); } setModal = (modal: ?React.Node) => { this.setState({ currentModal: modal }); }; clearModal() { this.setModal(null); } clearVerificationModal = () => { const navInfo = { ...this.props.navInfo, verify: null }; const newURL = canonicalURLFromReduxState( navInfo, this.props.location.pathname, this.props.loggedIn, ); if (newURL !== this.props.location.pathname) { history.push(newURL); } }; onClickCalendar = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'calendar' }, }); }; onClickChat = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat', activeChatThreadID: this.props.activeThreadCurrentlyUnread ? this.props.mostRecentReadThread : this.props.navInfo.activeChatThreadID, }, }); }; } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); export default React.memo(function ConnectedApp(props: BaseProps) { const activeChatThreadID = useSelector( (state) => state.navInfo.activeChatThreadID, ); const navInfo = useSelector((state) => state.navInfo); const serverVerificationResult = useSelector( (state) => state.serverVerificationResult, ); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const mostRecentReadThread = useSelector(mostRecentReadThreadSelector); const activeThreadCurrentlyUnread = useSelector( (state) => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const boundUnreadCount = useSelector(unreadCount); React.useEffect(() => { document.title = getTitle(boundUnreadCount); }, [boundUnreadCount]); const dispatch = useDispatch(); return ( ); }); diff --git a/web/calendar/calendar.react.js b/web/calendar/calendar.react.js index 893bb733c..54296c6ad 100644 --- a/web/calendar/calendar.react.js +++ b/web/calendar/calendar.react.js @@ -1,287 +1,287 @@ // @flow import { faFilter } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import dateFormat from 'dateformat'; import invariant from 'invariant'; import * as React from 'react'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from 'lib/actions/entry-actions'; import { currentDaysToEntries } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { type EntryInfo, type CalendarQuery, type CalendarQueryUpdateResult, type CalendarQueryUpdateStartingPayload, } from 'lib/types/entry-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import { getDate, dateString, startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils'; -import { type NavInfo } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import { yearAssertingSelector, monthAssertingSelector, webCalendarQuery, } from '../selectors/nav-selectors'; +import type { NavInfo } from '../types/nav-types'; import { canonicalURLFromReduxState } from '../url-utils'; import css from './calendar.css'; import Day from './day.react'; import FilterPanel from './filter-panel.react'; type BaseProps = {| +setModal: (modal: ?React.Node) => void, +url: string, |}; type Props = {| ...BaseProps, +year: number, +month: number, +daysToEntries: { [dayString: string]: EntryInfo[] }, +navInfo: NavInfo, +currentCalendarQuery: () => CalendarQuery, +loggedIn: boolean, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, |}; type State = {| filterPanelOpen: boolean, |}; class Calendar extends React.PureComponent { state: State = { filterPanelOpen: false, }; getDate( dayOfMonth: number, monthInput: ?number = undefined, yearInput: ?number = undefined, ) { return getDate( yearInput ? yearInput : this.props.year, monthInput ? monthInput : this.props.month, dayOfMonth, ); } prevMonthDates() { const { year, month } = this.props; const lastMonthDate = getDate(year, month - 1, 1); const prevYear = lastMonthDate.getFullYear(); const prevMonth = lastMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(prevYear, prevMonth), endDate: endDateForYearAndMonth(prevYear, prevMonth), }; } nextMonthDates() { const { year, month } = this.props; const nextMonthDate = getDate(year, month + 1, 1); const nextYear = nextMonthDate.getFullYear(); const nextMonth = nextMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(nextYear, nextMonth), endDate: endDateForYearAndMonth(nextYear, nextMonth), }; } render() { const { year, month } = this.props; const monthName = dateFormat(getDate(year, month, 1), 'mmmm'); const prevURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.prevMonthDates() }, this.props.url, this.props.loggedIn, ); const nextURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.nextMonthDates() }, this.props.url, this.props.loggedIn, ); const lastDayOfMonth = this.getDate(0, this.props.month + 1); const totalDaysInMonth = lastDayOfMonth.getDate(); const firstDayToPrint = 1 - this.getDate(1).getDay(); const lastDayToPrint = totalDaysInMonth + 6 - lastDayOfMonth.getDay(); const rows = []; let columns = []; let week = 1; let tabIndex = 1; for ( let curDayOfMonth = firstDayToPrint; curDayOfMonth <= lastDayToPrint; curDayOfMonth++ ) { if (curDayOfMonth < 1 || curDayOfMonth > totalDaysInMonth) { columns.push(); } else { const dayString = dateString( this.props.year, this.props.month, curDayOfMonth, ); const entries = this.props.daysToEntries[dayString]; invariant( entries, 'the currentDaysToEntries selector should make sure all dayStrings ' + `in the current range have entries, but ${dayString} did not`, ); columns.push( , ); tabIndex += entries.length; } if (columns.length === 7) { rows.push({columns}); columns = []; } } let filterPanel = null; let calendarContentStyle = null; let filterButtonStyle = null; if (this.state.filterPanelOpen) { filterPanel = ; calendarContentStyle = { marginLeft: '300px' }; filterButtonStyle = { backgroundColor: 'rgba(0,0,0,0.67)' }; } return (
{filterPanel}
Filters

<
{' '} {monthName} {year}{' '}
>

{rows}
Sunday Monday Tuesday Wednesday Thursday Friday Saturday
); } toggleFilters = (event: SyntheticEvent) => { event.preventDefault(); this.setState({ filterPanelOpen: !this.state.filterPanelOpen }); }; onClickPrevURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.prevMonthDates(), }; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(newCalendarQuery, true), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; onClickNextURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.nextMonthDates(), }; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(newCalendarQuery, true), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; } export default React.memo(function ConnectedCalendar( props: BaseProps, ) { const year = useSelector(yearAssertingSelector); const month = useSelector(monthAssertingSelector); const daysToEntries = useSelector(currentDaysToEntries); const navInfo = useSelector((state) => state.navInfo); const currentCalendarQuery = useSelector(webCalendarQuery); const loggedIn = useSelector(isLoggedIn); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }); diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index d9760077c..064f1edc3 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,221 +1,221 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { splitRobotext, parseRobotextEntity } from 'lib/shared/message-utils'; import { useSidebarExistsOrCanBeCreated } from 'lib/shared/thread-utils'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import Markdown from '../markdown/markdown.react'; import { linkRules } from '../markdown/rules.react'; -import { updateNavInfoActionType } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; +import { updateNavInfoActionType } from '../types/nav-types'; import css from './chat-message-list.css'; import { InlineSidebar } from './inline-sidebar.react'; import type { MessagePositionInfo, OnMessagePositionWithContainerInfo, } from './position-types'; import MessageActionTooltip from './sidebar-tooltip.react'; import { tooltipPositions } from './tooltip-utils'; const availableTooltipPositionsForRobotext = [ tooltipPositions.TOP_RIGHT, tooltipPositions.RIGHT, tooltipPositions.LEFT, ]; type BaseProps = {| +item: RobotextChatMessageInfoItem, +threadInfo: ThreadInfo, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, |}; type Props = {| ...BaseProps, // Redux state +sidebarExistsOrCanBeCreated: boolean, |}; class RobotextMessage extends React.PureComponent { render() { let inlineSidebar; if (this.props.item.threadCreatedFromMessage) { inlineSidebar = (
); } const { item, threadInfo, sidebarExistsOrCanBeCreated } = this.props; const { id } = item.messageInfo; let messageActionTooltip; if ( this.props.mouseOverMessagePosition && this.props.mouseOverMessagePosition.item.messageInfo.id === id && sidebarExistsOrCanBeCreated ) { messageActionTooltip = ( ); } let messageActionLinks; if (messageActionTooltip) { messageActionLinks = (
{messageActionTooltip}
); } return (
{this.linkedRobotext()} {messageActionLinks}
{inlineSidebar}
); } linkedRobotext() { const { item } = this.props; const { robotext } = item; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; for (const splitPart of robotextParts) { if (splitPart === '') { continue; } if (splitPart.charAt(0) !== '<') { const key = `text${keyIndex++}`; textParts.push( {decodeURI(splitPart)} , ); continue; } const { rawText, entityType, id } = parseRobotextEntity(splitPart); if (entityType === 't' && id !== item.messageInfo.threadID) { textParts.push(); } else if (entityType === 'c') { textParts.push(); } else { textParts.push(rawText); } } return textParts; } onMouseEnter = (event: SyntheticEvent) => { const { item } = this.props; const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const messagePosition = { top, bottom, left, right, height, width }; this.props.setMouseOverMessagePosition({ type: 'on', item, messagePosition, }); }; onMouseLeave = () => { const { item } = this.props; this.props.setMouseOverMessagePosition({ type: 'off', item }); }; } type BaseInnerThreadEntityProps = {| +id: string, +name: string, |}; type InnerThreadEntityProps = {| ...BaseInnerThreadEntityProps, +threadInfo: ThreadInfo, +dispatch: Dispatch, |}; class InnerThreadEntity extends React.PureComponent { render() { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: id, }, }); }; } const ThreadEntity = React.memo( function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) { const { id } = props; const threadInfo = useSelector((state) => threadInfoSelector(state)[id]); const dispatch = useDispatch(); return ( ); }, ); function ColorEntity(props: {| color: string |}) { const colorStyle = { color: props.color }; return {props.color}; } export default React.memo(function ConnectedRobotextMessage( props: BaseProps, ) { const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( props.threadInfo, props.item, ); return ( ); }); diff --git a/web/redux/nav-reducer.js b/web/redux/nav-reducer.js new file mode 100644 index 000000000..dc903e20c --- /dev/null +++ b/web/redux/nav-reducer.js @@ -0,0 +1,14 @@ +// @flow + +import type { Action } from '../redux/redux-setup'; +import { type NavInfo, updateNavInfoActionType } from '../types/nav-types'; + +export default function reduceNavInfo(state: NavInfo, action: Action): NavInfo { + if (action.type === updateNavInfoActionType) { + return { + ...state, + ...action.payload, + }; + } + return state; +} diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index 6c66b7090..faaa3fd45 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,209 +1,201 @@ // @flow import invariant from 'invariant'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { mostRecentReadThreadSelector } from 'lib/selectors/thread-selectors'; import { invalidSessionDowngrade } from 'lib/shared/account-utils'; import type { Shape } from 'lib/types/core'; import type { EntryStore } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; -import type { BaseNavInfo } from 'lib/types/nav-types'; import type { BaseAction } from 'lib/types/redux-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import type { ConnectionInfo } from 'lib/types/socket-types'; -import type { ThreadInfo, ThreadStore } from 'lib/types/thread-types'; +import type { ThreadStore } from 'lib/types/thread-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { activeThreadSelector } from '../selectors/nav-selectors'; +import { type NavInfo, updateNavInfoActionType } from '../types/nav-types'; import { updateWindowActiveActionType } from './action-types'; +import reduceNavInfo from './nav-reducer'; import { getVisibility } from './visibility'; -export type NavInfo = {| - ...$Exact, - +tab: 'calendar' | 'chat', - +verify: ?string, - +activeChatThreadID: ?string, - +pendingThread?: ThreadInfo, - +sourceMessageID?: string, -|}; - export type WindowDimensions = {| width: number, height: number |}; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, sessionID: ?string, serverVerificationResult: ?ServerVerificationResult, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, windowDimensions: WindowDimensions, cookie?: void, deviceToken?: void, baseHref: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, lifecycleState: LifecycleState, nextLocalID: number, queuedReports: $ReadOnlyArray, timeZone: ?string, userAgent: ?string, dataLoaded: boolean, windowActive: boolean, |}; -export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; export const updateWindowDimensions = 'UPDATE_WINDOW_DIMENSIONS'; export type Action = | BaseAction | {| type: 'UPDATE_NAV_INFO', payload: Shape |} | {| type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, |} | {| type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, |}; export function reducer(oldState: AppState | void, action: Action) { invariant(oldState, 'should be set'); let state = oldState; - if (action.type === updateNavInfoActionType) { - return validateState(oldState, { - ...state, - navInfo: { - ...state.navInfo, - ...action.payload, - }, - }); - } else if (action.type === updateWindowDimensions) { + if (action.type === updateWindowDimensions) { return validateState(oldState, { ...state, windowDimensions: action.payload, }); } else if (action.type === updateWindowActiveActionType) { return validateState(oldState, { ...state, windowActive: action.payload, }); } else if (action.type === setNewSessionActionType) { if ( invalidSessionDowngrade( oldState, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, ) ) { return oldState; } state = { ...state, sessionID: action.payload.sessionChange.sessionID, }; } else if ( (action.type === logOutActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return oldState; } - return validateState(oldState, baseReducer(state, action)); + if (action.type !== updateNavInfoActionType) { + state = baseReducer(state, action); + } + + state = { + ...state, + navInfo: reduceNavInfo(state.navInfo, action), + }; + + return validateState(oldState, state); } function validateState(oldState: AppState, state: AppState): AppState { if ( state.navInfo.activeChatThreadID && !state.navInfo.pendingThread && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID] ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !getVisibility().hidden() && typeof document !== 'undefined' && document && document.hasFocus && document.hasFocus() && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread ) { // Makes sure a currently focused thread is never unread state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { // Update messageStore.threads[activeThread].lastNavigatedTo state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [activeThread]: { ...state.messageStore.threads[activeThread], lastNavigatedTo: Date.now(), }, }, }, }; } return state; } diff --git a/web/selectors/nav-selectors.js b/web/selectors/nav-selectors.js index a7fd2d2e3..19bed5f73 100644 --- a/web/selectors/nav-selectors.js +++ b/web/selectors/nav-selectors.js @@ -1,189 +1,189 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { nonThreadCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors'; import { currentCalendarQuery } from 'lib/selectors/nav-selectors'; import { createPendingSidebar } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { ComposableMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { getDefaultTextMessageRules } from '../markdown/rules.react'; import type { AppState } from '../redux/redux-setup'; -import { updateNavInfoActionType } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; +import { updateNavInfoActionType } from '../types/nav-types'; const dateExtractionRegex = /^([0-9]{4})-([0-9]{2})-[0-9]{2}$/; function yearExtractor(startDate: string, endDate: string): ?number { const startDateResults = dateExtractionRegex.exec(startDate); const endDateResults = dateExtractionRegex.exec(endDate); if ( !startDateResults || !startDateResults[1] || !endDateResults || !endDateResults[1] || startDateResults[1] !== endDateResults[1] ) { return null; } return parseInt(startDateResults[1], 10); } function yearAssertingExtractor(startDate: string, endDate: string): number { const result = yearExtractor(startDate, endDate); invariant( result !== null && result !== undefined, `${startDate} and ${endDate} aren't in the same year`, ); return result; } const yearAssertingSelector: (state: AppState) => number = createSelector( (state: AppState) => state.navInfo.startDate, (state: AppState) => state.navInfo.endDate, yearAssertingExtractor, ); // 1-indexed function monthExtractor(startDate: string, endDate: string): ?number { const startDateResults = dateExtractionRegex.exec(startDate); const endDateResults = dateExtractionRegex.exec(endDate); if ( !startDateResults || !startDateResults[1] || !startDateResults[2] || !endDateResults || !endDateResults[1] || !endDateResults[2] || startDateResults[1] !== endDateResults[1] || startDateResults[2] !== endDateResults[2] ) { return null; } return parseInt(startDateResults[2], 10); } // 1-indexed function monthAssertingExtractor(startDate: string, endDate: string): number { const result = monthExtractor(startDate, endDate); invariant( result !== null && result !== undefined, `${startDate} and ${endDate} aren't in the same month`, ); return result; } // 1-indexed const monthAssertingSelector: (state: AppState) => number = createSelector( (state: AppState) => state.navInfo.startDate, (state: AppState) => state.navInfo.endDate, monthAssertingExtractor, ); function activeThreadSelector(state: AppState): ?string { return state.navInfo.tab === 'chat' ? state.navInfo.activeChatThreadID : null; } const webCalendarQuery: ( state: AppState, ) => () => CalendarQuery = createSelector( currentCalendarQuery, (state: AppState) => state.navInfo.tab === 'calendar', ( calendarQuery: (calendarActive: boolean) => CalendarQuery, calendarActive: boolean, ) => () => calendarQuery(calendarActive), ); const nonThreadCalendarQuery: ( state: AppState, ) => () => CalendarQuery = createSelector( webCalendarQuery, nonThreadCalendarFiltersSelector, ( calendarQuery: () => CalendarQuery, filters: $ReadOnlyArray, ) => { return (): CalendarQuery => { const query = calendarQuery(); return { startDate: query.startDate, endDate: query.endDate, filters, }; }; }, ); function useOnClickThread(threadID: string) { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: threadID, }, }); }, [dispatch, threadID], ); } function useThreadIsActive(threadID: string) { return useSelector((state) => threadID === state.navInfo.activeChatThreadID); } function useOnClickPendingSidebar( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, ) { const dispatch = useDispatch(); const viewerID = useSelector((state) => state.currentUserInfo?.id); return React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (!viewerID) { return; } const pendingSidebarInfo = createPendingSidebar( messageInfo, threadInfo, viewerID, getDefaultTextMessageRules().simpleMarkdownRules, ); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: pendingSidebarInfo.id, pendingThread: pendingSidebarInfo, sourceMessageID: messageInfo.id, }, }); }, [viewerID, messageInfo, threadInfo, dispatch], ); } export { yearExtractor, yearAssertingSelector, monthExtractor, monthAssertingSelector, activeThreadSelector, webCalendarQuery, nonThreadCalendarQuery, useOnClickThread, useThreadIsActive, useOnClickPendingSidebar, }; diff --git a/web/types/nav-types.js b/web/types/nav-types.js new file mode 100644 index 000000000..6a74fad8a --- /dev/null +++ b/web/types/nav-types.js @@ -0,0 +1,15 @@ +// @flow + +import type { BaseNavInfo } from 'lib/types/nav-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; + +export type NavInfo = {| + ...$Exact, + +tab: 'calendar' | 'chat', + +verify: ?string, + +activeChatThreadID: ?string, + +pendingThread?: ThreadInfo, + +sourceMessageID?: string, +|}; + +export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; diff --git a/web/url-utils.js b/web/url-utils.js index 6e3041840..c9158a9cc 100644 --- a/web/url-utils.js +++ b/web/url-utils.js @@ -1,113 +1,113 @@ // @flow import invariant from 'invariant'; import { startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils'; import { infoFromURL } from 'lib/utils/url-utils'; -import type { NavInfo } from './redux/redux-setup'; import { yearExtractor, monthExtractor } from './selectors/nav-selectors'; +import type { NavInfo } from './types/nav-types'; function canonicalURLFromReduxState( navInfo: NavInfo, currentURL: string, loggedIn: boolean, ) { const urlInfo = infoFromURL(currentURL); const today = new Date(); let newURL = `/`; if (loggedIn) { newURL += `${navInfo.tab}/`; if (navInfo.tab === 'calendar') { const { startDate, endDate } = navInfo; const year = yearExtractor(startDate, endDate); if (urlInfo.year !== undefined) { invariant( year !== null && year !== undefined, `${startDate} and ${endDate} aren't in the same year`, ); newURL += `year/${year}/`; } else if ( year !== null && year !== undefined && year !== today.getFullYear() ) { newURL += `year/${year}/`; } const month = monthExtractor(startDate, endDate); if (urlInfo.month !== undefined) { invariant( month !== null && month !== undefined, `${startDate} and ${endDate} aren't in the same month`, ); newURL += `month/${month}/`; } else if ( month !== null && month !== undefined && month !== today.getMonth() + 1 ) { newURL += `month/${month}/`; } } else if (navInfo.tab === 'chat') { const activeChatThreadID = navInfo.activeChatThreadID; if (activeChatThreadID) { newURL += `thread/${activeChatThreadID}/`; } } } if (navInfo.verify) { newURL += `verify/${navInfo.verify}/`; } return newURL; } // Given a URL, this function parses out a navInfo object, leaving values as // default if they are unspecified. function navInfoFromURL( url: string, backupInfo: {| now?: Date, navInfo?: NavInfo |}, ): NavInfo { const urlInfo = infoFromURL(url); const { navInfo } = backupInfo; const now = backupInfo.now ? backupInfo.now : new Date(); let year = urlInfo.year; if (!year && navInfo) { year = yearExtractor(navInfo.startDate, navInfo.endDate); } if (!year) { year = now.getFullYear(); } let month = urlInfo.month; if (!month && navInfo) { month = monthExtractor(navInfo.startDate, navInfo.endDate); } if (!month) { month = now.getMonth() + 1; } let activeChatThreadID = null; if (urlInfo.thread) { activeChatThreadID = urlInfo.thread.toString(); } else if (navInfo) { activeChatThreadID = navInfo.activeChatThreadID; } return { tab: urlInfo.chat ? 'chat' : 'calendar', startDate: startDateForYearAndMonth(year, month), endDate: endDateForYearAndMonth(year, month), activeChatThreadID, verify: urlInfo.verify ? urlInfo.verify : null, }; } export { canonicalURLFromReduxState, navInfoFromURL };